4.0. Typing
In one glance
- You will: Trace one malformed tool argument from the model to its rejection, then run the type checker and the boundary tests that stop it.
- You need: Chapter 3 finished and
mise run doctorpassing. - Time: about 18 minutes, concept.
What is typing, and what are the two kinds?
Here is the failure this whole page exists to prevent.
flowchart TD
A["Model emits<br/>{incident_id: '../../etc/passwd'}"] --> B{{"validate_actions<br/>(before-tool callback)"}}
B --> C[normalize_incident_id]
C -->|"no match → None"| R["Stable {error: ...}<br/>tool never runs"]
R -.fed back.-> A
C -->|"'INC-002' → normalized"| T[resolve_incident]
T --> D["data.py<br/>parameterized SQL"]
The model asked to resolve ../../etc/passwd, and the tool never ran. Nothing in that rejection depended on the model cooperating. Two mechanisms did the work, and beginners routinely conflate them.
A type is a promise about what a value is. Python lets you write that promise down (def get_incident(incident_id: str) -> Incident:) but does not enforce it — annotations are ignored at runtime. That surprises people, so it is worth being exact about the two different mechanisms this chapter uses:
| Static typing (ty) | Runtime validation (Pydantic) | |
|---|---|---|
| Runs | Before the code runs, reading source | While the code runs, on real data |
| Catches | Code that cannot be right — wrong types wired together | Data that is not right — a bad value arriving |
| Costs | Nothing at runtime | A few microseconds per validation |
| Blind to | What the outside world actually sends | Mistakes on paths it never executes |
They are complementary, and neither substitutes for the other. Static checking proves you wired your own code together correctly. It cannot know that the JSON a model just produced has severity: "extremely bad". Validation catches that — but only if you put a validator on the boundary where it arrives.
The rule the whole chapter follows: parse at the boundary, then trust. A boundary is any place data crosses from the outside world into your code.
Convert untrusted external data into a validated type once, at the edge, and let everything inside operate on values that are already known-good. The alternative — checking if x is None defensively in twenty places, and missing the twenty-first — is how most production bugs are born.
flowchart LR
subgraph Untrusted["Untrusted outside"]
M[Model output]
E[Environment]
D[SQLite]
N[MCP / A2A]
end
Untrusted --> P{{Parse + validate<br/>Pydantic}}
P -->|rejected| Err[Stable error<br/>never reaches logic]
P -->|accepted| T[Trusted typed values]
T --> L[Business logic<br/>checked by ty]
Why does type safety matter more for an agent?
Because an agent's inputs are generated by something that is fluent but not accountable, and its outputs can restart a service.
In ordinary software, a function's caller is other code you wrote — a compiler or a test can pin down what it sends. In an agent, the caller of restart_service is a language model that produced the argument by predicting plausible text (2.2. Models). It has no obligation to be right.
It can hallucinate an incident ID that has never existed, invent a service name that reads convincingly, or pass "../../etc/passwd" because a poisoned log line suggested it. None of that is a bug in the model; it is the model doing what it does.
So the agent's most dangerous data path — untrusted text → tool arguments → real side effects — is exactly the one a traditional type checker cannot see through. Types are how you close it:
- Strings arrive from everywhere: prompts, environment, MCP, A2A, SQLite, providers. Every one is a potential injection or a typo.
extra="forbid"makes unexpected fields loud. A model that invents a plausible extra key gets a rejection, not silent acceptance.- A constrained type is a security control.
Field(pattern=_INCIDENT_ID.pattern)means a path-traversal string cannot become an incident ID — not because you remembered to check, but because the type will not hold it. - Enums make illegal states unrepresentable. If
Severityis an enum,"extremely bad"cannot reach your logic and cause a wrong branch three functions later.
That "security control" claim is concrete, not a slogan. It is the diagram at the top of this page, step by step.
The before-tool callback — code ADK runs after the model picks a tool but before the tool executes — is validate_actions (guardrails.py). It runs normalize_incident_id on the argument. A value that does not match ^INC-\d+$ becomes None, and the callback returns a stable {"error": ...} dict. ADK feeds that dict back to the model as the tool result, without ever running the tool. Only a value that survives narrowing reaches resolve_incident and the parameterized SQL in data.py.
Types do not prove that a model answer is correct. They prevent malformed data from becoming an unexamined incident, service, action target, or audit record. They also turn a class of failures from "mysterious behavior in production" into "a rejection at the edge, with a stable error and a trace".
Where are the trusted boundaries?
Every place external data enters gets parsed into a typed value before any logic runs. The domain models in models.py parse SQLite rows; the constraints are module-level regexes so the same pattern guards a model field and a normalizer:
_INCIDENT_ID = re.compile(r"^INC-\d+$")
_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
# ... StrEnums (IncidentStatus, Severity, ServiceStatus), Service model ...
class Incident(BaseModel):
"""One incident parsed from the trusted dataset."""
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=_INCIDENT_ID.pattern)
service: str = Field(pattern=_SLUG.pattern)
title: str = Field(min_length=1)
severity: Severity
status: IncidentStatus
runbook: str = Field(pattern=_SLUG.pattern)
opened_at: str = Field(min_length=1)
resolved_at: str | None
summary: str = Field(min_length=1)
Each boundary has a real owner in the code:
- Environment →
Settings.config.pyparsesAGENT_*and provider SDK variables into paths, booleans, bounded ports (a2a_port: int = Field(ge=1, le=65535)), a protocol (a2a_protocol: str = Field(pattern=r"^https?$")), and call budgets. See the next section — it is the richest boundary in the repo. - SQLite rows → domain models.
list_incidents/get_incidentindata.pywrap every row inIncident.model_validate(dict(row));list_servicesdoes the same withService.extra="forbid"means an unexpected column is a loudValidationError, not a silently-ignored field. - Lifecycle strings → enums.
IncidentStatus,Severity, andServiceStatusareStrEnums — enum members that are also ordinary strings. A row whosestatusis not one of the three legal values is rejected at parse time rather than mis-branched later. - Model-controlled ids/slugs → normalizers.
normalize_incident_idandnormalize_slugnarrow a tool argument before it touches SQL or the filesystem.read_runbookandread_service_logsindata.pycallnormalize_slugfirst, so a traversal slug is treated as "not found" rather than read. - Tool results → plain JSON. After validation, tool outputs are ordinary JSON-compatible dicts; the typed models stay at the edge and the interior passes plain structures.
The narrowing functions are short and total — every possible input maps either to a strict value or to None:
def normalize_incident_id(value: str) -> str | None:
"""Normalize a model-supplied incident id, returning ``None`` when invalid."""
normalized = value.strip().upper()
return normalized if _INCIDENT_ID.fullmatch(normalized) else None
There is no third outcome: the return either matches ^INC-\d+$ or is None. 4.2. Testing proves exactly that with a Hypothesis property (test_incident_id_normalization_has_no_third_state) that fuzzes the whole unicode/control-character input space, plus a companion property that no traversal or SQL metacharacter survives into an accepted id.
Deeper: why a narrowing function instead of a Pydantic model?
There is a third, quieter mechanism this chapter also relies on and 4.2 tests: a total narrowing function. Some untrusted values do not arrive as a model you construct — a model-supplied tool argument reaches your callback as an already-str value inside a dict[str, Any], with no Pydantic construction step to hang a validator on. For those, the repo uses normalize_incident_id / normalize_slug: pure functions typed str -> str | None that return the strict value or None, with no third outcome. They are runtime validation expressed as a function instead of a model.
How does configuration become a trusted boundary?
Run the resolved configuration through the CLI and watch the boundary check it, with secrets masked:
cd agents/python
mise run config:check
A valid environment prints a header and then every field, sorted by name:
Agent configuration is valid. Resolved settings (secrets masked):
- a2a_bind_host = 127.0.0.1
Each secret in that list is printed as **********, so the report is safe to paste into an issue. A contradictory environment exits non-zero instead, naming the fix:
Agent configuration is invalid:
- Value error, AGENT_MODEL_PROVIDER=openai-compatible requires OPENAI_BASE_URL. Use http://127.0.0.1:11434/v1 for direct Ollama or http://127.0.0.1:4000/v1 for the host agentgateway model route.
That second block is what a boundary rejection looks like: one line per problem, naming the variable and the value to use. The rest of this section is how the boundary produces it.
Configuration is untrusted input like any other — it just arrives from the environment instead of the model. The failure mode is worse, though: a bad combination of environment variables surfaces as a stack trace deep inside the first turn. That is far harder to debug than a value that is wrong on its face.
So Settings follows the same rule — parse once, fail fast — and pushes it further: make an illegal configuration unrepresentable.
Individual fields carry their own constraints, and the provider fields carry the aliases and defaults that select Gemini by default and support explicit OpenAI-compatible alternatives:
entrypoint: AgentEntrypoint = AgentEntrypoint.AGENT
model_provider: ModelProvider = ModelProvider.GEMINI
# Keep the learner and gateway model aligned with the qualified platform pair.
model: str = Field(default="gemini-3.5-flash", min_length=1)
# ``openai-compatible`` describes the ADK client contract, not the
# deployment topology. These defaults support the optional Ollama path;
# Part II instead points the adapter at agentgateway.
openai_base_url: str | None = Field(
default="http://127.0.0.1:11434/v1",
validation_alias=AliasChoices("OPENAI_BASE_URL"),
)
openai_api_key: SecretStr | None = Field(
default=SecretStr("local-ollama"),
validation_alias=AliasChoices("OPENAI_API_KEY"),
)
# Default Gemini path: an AI Studio API key; optional Enterprise/Vertex via ADC
# with an explicit project and location.
google_api_key: SecretStr | None = Field(
default=None,
validation_alias=AliasChoices("GOOGLE_API_KEY"),
)
google_genai_use_enterprise: bool = Field(
default=False,
validation_alias=AliasChoices("GOOGLE_GENAI_USE_ENTERPRISE"),
)
google_cloud_project: str | None = Field(
default=None,
validation_alias=AliasChoices("GOOGLE_CLOUD_PROJECT"),
)
google_cloud_location: str | None = Field(
default=None,
validation_alias=AliasChoices("GOOGLE_CLOUD_LOCATION"),
)
A per-field constraint cannot express "OpenAI-compatible needs a base URL" or "enterprise Gemini needs a project and a location" — those are relationships between fields. A @model_validator(mode="after") runs once, after every field is parsed, and rejects the bad combinations with a message that names the fix instead of a stack trace:
provider_problems: list[str] = []
if self.deprecated_gateway_enabled is not None:
provider_problems.append(
"AGENT_GATEWAY_ENABLED was removed. Keep AGENT_MODEL_PROVIDER=openai-compatible "
"and select direct Ollama or agentgateway with OPENAI_BASE_URL "
"(http://127.0.0.1:11434/v1 or http://127.0.0.1:4000/v1)."
)
if self.model_provider is ModelProvider.OPENAI_COMPATIBLE and not self.openai_base_url:
provider_problems.append(
"AGENT_MODEL_PROVIDER=openai-compatible requires OPENAI_BASE_URL. Use "
"http://127.0.0.1:11434/v1 for direct Ollama or http://127.0.0.1:4000/v1 "
"for the host agentgateway model route."
)
if self.model_provider is ModelProvider.OPENAI_COMPATIBLE and (
not self.openai_api_key or not self.openai_api_key.get_secret_value().strip()
):
provider_problems.append(
"AGENT_MODEL_PROVIDER=openai-compatible requires OPENAI_API_KEY. Ollama and the open "
"local gateway accept a non-secret marker such as local-ollama."
)
google_api_key = self.google_api_key.get_secret_value().strip() if self.google_api_key else ""
if self.model_provider is ModelProvider.GEMINI:
if google_api_key and self.google_genai_use_enterprise:
provider_problems.append(
"AGENT_MODEL_PROVIDER=gemini cannot combine GOOGLE_API_KEY with "
"GOOGLE_GENAI_USE_ENTERPRISE=true in this course. Choose AI Studio API-key auth "
"or the ADC-backed enterprise path."
)
elif self.google_genai_use_enterprise:
missing_enterprise = [
name
for name, value in (
("GOOGLE_CLOUD_PROJECT", self.google_cloud_project),
("GOOGLE_CLOUD_LOCATION", self.google_cloud_location),
)
if not isinstance(value, str) or not value.strip()
]
if missing_enterprise:
provider_problems.append(
"AGENT_MODEL_PROVIDER=gemini with GOOGLE_GENAI_USE_ENTERPRISE=true requires "
+ " and ".join(missing_enterprise)
+ " for the ADC-backed course path."
)
if provider_problems:
raise ValueError("\n".join(provider_problems))
This is "make illegal states unrepresentable" applied to configuration: the type system rules out a malformed port; the cross-field validator rules out a coherent-looking but contradictory provider setup.
tests/test_config.py is the copy target when you add a setting. test_openai_compatible_provider_requires_base_url and test_gemini_provider_rejects_ambiguous_api_key_and_enterprise_auth each set one bad environment value and assert the boundary rejects it with the right message.
How is the code checked statically?
The repository uses ty, Astral's static type checker, against Python 3.13:
[tool.ty.environment]
python-version = "3.13"
ty reads source and never runs your code, so it catches wiring mistakes at zero runtime cost. Agent mise run check fans out formatting, linting, and typing. Root check:vuln stays outside the per-commit hook and runs in the maintainer gate and CI because it queries package advisories.
Deeper: why is ty pinned to a version range?
ty is still pre-1.0, which is why pyproject.toml owns a bounded compatible range rather than an open lower bound; an unbounded pre-1.0 checker can change its diagnostics underneath the course. Read the active range in the manifest instead of copying it into prose.
Run it alone while iterating:
cd agents/python
mise run check:types
A real failure looks like a call site, not a runtime crash. Write list_incidents(status="open") when list_incidents in data.py declares status: IncidentStatus | None: a plain str is not an IncidentStatus (the enum is a subtype of str, not the reverse), so ty rejects that line before a single test runs. The fix is to pass IncidentStatus.OPEN, not to silence the diagnostic.
Do not silence an error with Any or an ignore unless the external library boundary genuinely cannot be expressed. Keep any necessary ignore narrow and explain why runtime compatibility is still safe — the model-selection tests do this deliberately, using # noqa: SLF001 on the few asserts that reach into a locked SDK's private attribute to verify the resilience seam.
Which invariants deserve enums or validated types?
Use a type when an invalid value should be unrepresentable after parsing. That covers incident status, severity, service status, incident ids, slugs, A2A protocol, ports, and max model calls. Each of those has a small, closed set of legal values or a strict shape, so a wrapper earns its keep:
class Severity(StrEnum):
"""Incident severity, ordered by its numeric suffix."""
SEV1 = "SEV1"
SEV2 = "SEV2"
SEV3 = "SEV3"
Do not introduce wrapper types for strings that have no meaningful invariant. The repo draws that line inside a single model: Incident.severity is a Severity enum, but Incident.summary is only Field(min_length=1).
A summary is free prose — its sole invariant is "not empty", and a Summary wrapper would add ceremony without ruling out any real mistake. severity has exactly three legal values, so an enum turns a whole class of bugs (a typo'd or invented severity flowing into a routing decision) into a parse-time rejection.
Reach for a type where the value space is closed and a wrong value is dangerous; stop where it is open and a wrong value is merely wrong text.
What happens when the model violates the schema?
Something explicit happens: the agent retries once, then degrades to prose. It never crashes and never returns a wrong-shaped object.
triage_report_agent (2.3. Instructions) promises a validated TriageReport; the programmatic path in report.py decides what a violation means. parse_triage_report lets every real violation raise ValidationError, and request_triage_report retries once with the validation errors fed back, then degrades to prose:
second = await generate(retry_prompt)
try:
return parse_triage_report(second)
except ValidationError:
_SCHEMA_FAILURES.add(1)
logger.warning("Triage report for %s failed schema validation twice; degrading to prose", incident_id)
return second
That retry-then-degrade policy exists because the constraint you declare is not the constraint that gets enforced. ADK does not merely ask the model nicely: it translates output_schema into response_format: {"type": "json_schema"} on the wire, so the serving runtime is told exactly what shape to produce. Whether that request is enforced is the runtime's decision — a server implementing grammar-constrained decoding makes malformed JSON unrepresentable, while one that treats response_format as a hint hands you free-form prose with a schema-shaped intention. So size your parser tolerance to the endpoint you actually run, not to the endpoint you wish you ran: verify once what your serving stack does with a deliberately hard schema, write the answer down next to the model pin, and keep the tolerant path only for as long as your endpoint needs it.
The return type is TriageReport | str, so the caller must handle both outcomes — never a silent crash and never a silently-wrong object (extra="forbid" rejects a plausible-looking report with an invented field). Each degradation increments the agentops.triage_report.schema_failures counter, so a rising violation rate is a dashboard signal of model quality, not a hidden branch. The policy is deterministic to test offline with a fake model:
cd agents/python
uv run pytest tests/test_report.py
The separate model-backed checkpoint exercises the actual ADK output_schema entry point without changing the conversational agent.
This one needs a configured model
Every other command on this page runs offline, including the checkpoint at the bottom. mise run eval:report calls the model, so set the four provider values from the Before you run any model-backed task tip on 4.4. Evaluations first. Skipping this task costs you nothing on this page.
cd agents/python
mise run eval:report
triage-report.evalset.json requires the incident, log, and runbook reads in order. A completed run has therefore crossed both boundaries: ADK accepted the final response as a TriageReport, and the trajectory stayed grounded in the fixed seed.
Where does typing stop helping?
Types are a boundary control, not a force field. Four failure modes survive them. This is the one that bites everyone:
- A
StrEnumcompares equal to its own string, so a typo'd comparison still type-checks and silently fails.IncidentStatus.RESOLVED == "resolved"isTrue, which is convenient, butstatus == "resloved"also type-checks fine and just evaluatesFalseforever. The enum protects the values you store; it does not protect a bare string literal you compare against.data.pydefends by comparing to the enum member's value (incident["status"] == IncidentStatus.RESOLVED.value) rather than sprinkling raw literals.
Deeper: three more places types stop helping
extra="forbid"is loud on purpose — and brittle by the same token. It is right for the committed dataset, where a surprise column is a genuine defect, so the domain models forbid extras. It would be wrong for a schema you do not control:Settingsdeliberately usesextra="ignore"instead, so an unrelated ambient variable or a future provider field does not crash startup. Choose per boundary, not by habit.- Pydantic validates at construction, not on mutation. The models set no
validate_assignment=True, soincident.status = "bogus"after parsing would not re-validate and would silently hold a bad value. The models are safe because they are built once from a row viamodel_validateand never mutated — treat a parsed model as immutable in practice. - Annotations are erased at runtime. ADK hands your callbacks
args: dict[str, Any]andtool_response: dict[str, Any]; the annotation constrains nothing about what is actually inside at runtime. A signature that saysdict[str, Any]is not a promise about the contents — only real validation (model_validate, a normalizer) makes that data trustworthy, which is exactly why the model-controlled ids go throughnormalize_incident_idbefore use.
What proves this page worked?
Both gates run offline, with no model and no account:
cd agents/python
mise run check:types
uv run pytest tests/test_model.py tests/test_config.py tests/test_report.py tests/test_tools.py
Introduce one malformed database row or environment value in a test and confirm it fails at the boundary with context rather than propagating as a later tool error. Copy an existing pattern:
test_schema_rejects_extra_fields_and_bad_idsintests/test_report.pyfeeds a bad id and an invented field to a model and asserts theValidationError.test_openai_compatible_provider_requires_base_urlintests/test_config.pysets one bad environment value and asserts the fail-fast message.
A good check fails where the value arrives, naming the field — not three functions later inside a tool.
You are done when:
mise run check:typesends withAll checks passed!.- The four focused test files pass with a zero exit; the full
mise run testseparately clears the 95% combined line-and-branch coverage floor. mise run config:checkprintsAgent configuration is valid.and shows every secret as**********.- The malformed row or environment value you introduced was rejected at the boundary that owns it, naming the field.
- You can say, for a given boundary, whether static typing, a Pydantic model, or a narrowing function is what guards it.
Continue to 4.1. Linting when a bad value you introduce fails where it arrives, not three functions later.